<?php
/**
 * Authentication Manager
 *
 * @author Tim Gunter <tim@vanillaforums.com>
 * @copyright 2009-2019 Vanilla Forums Inc.
 * @license GPL-2.0-only
 * @package Core
 * @since 2.0.10
 */

/**
 * Manages the authentication system for vanilla, including all authentication modules.
 */
class Gdn_Auth extends Gdn_Pluggable
{
    /** @var array  */
    protected $_AuthenticationSchemes = [];

    /** @var object  */
    protected $_Authenticator = null;

    /** @var array  */
    protected $_Authenticators = [];

    /** @var string  */
    protected $_Protocol = "http";

    /** @var Gdn_CookieIdentity  */
    protected $_Identity = null;

    /** @var object  */
    protected $_UserModel = null;

    /** @var PermissionModel  */
    protected $_PermissionModel = null;

    /** @var bool  */
    protected $_AllowHandshake;

    /** @var bool  */
    protected $_Started = false;

    /**
     *
     */
    public function __construct()
    {
        // Prepare Identity storage container
        $this->identity();
        $this->_AllowHandshake = false;
        parent::__construct();
    }

    public function startAuthenticator()
    {
        if (!c("Garden.Installed", false)) {
            return;
        }

        // This event was created for token based authentication to hook on. See hook for more info.
        $this->fireEvent("startAuthenticator");

        // Start the 'session'
        if (!Gdn::session()->isValid()) {
            Gdn::session()->start(false, false);
        }

        // Get list of enabled authenticators
        $authenticationSchemes = Gdn::config("Garden.Authenticator.EnabledSchemes", []);

        // Bring all enabled authenticator classes into the defined scope to allow them to be picked up by the plugin manager
        foreach ($authenticationSchemes as $authenticationSchemeAlias) {
            $registered = $this->registerAuthenticator($authenticationSchemeAlias);
        }

        $this->_Started = true;
        $this->wakeUpAuthenticators();

        if (Gdn::session()->isValid() && !Gdn::session()->checkPermission("Garden.SignIn.Allow")) {
            return Gdn::authenticator()
                ->authenticateWith("user")
                ->deauthenticate();
        }
    }

    public function registerAuthenticator($AuthenticationSchemeAlias)
    {
        $AuthenticatorClassPath = PATH_LIBRARY . "/core/authenticators/class.%sauthenticator.php";
        $Alias = strtolower($AuthenticationSchemeAlias);
        $Path = sprintf($AuthenticatorClassPath, $Alias);
        $AuthenticatorClass = sprintf("Gdn_%sAuthenticator", ucfirst($Alias));
        // Include the class if it exists
        if (!class_exists($AuthenticatorClass, false) && file_exists($Path)) {
            require_once $Path;
        }

        if (class_exists($AuthenticatorClass)) {
            $this->_AuthenticationSchemes[$Alias] = [
                "Name" => c("Garden.Authenticators.{$Alias}.Name", $Alias),
                "Configure" => false,
            ];

            // Now wake it up so it can do setup work
            if ($this->_Started) {
                $Authenticator = $this->authenticateWith($Alias, false);
                $Authenticator->wakeUp();
            }
        }
    }

    public function wakeUpAuthenticators()
    {
        foreach ($this->_AuthenticationSchemes as $alias => $properties) {
            $authenticator = $this->authenticateWith($alias, false);
            $authenticator->wakeup();
        }
    }

    /**
     * Authenticate with a particular type of authenticator (ex. password).
     *
     * @param string $authenticationSchemeAlias
     * @param bool $inheritAuthenticator
     * @return mixed
     * @throws Exception
     */
    public function authenticateWith($authenticationSchemeAlias = "default", $inheritAuthenticator = true)
    {
        if ($authenticationSchemeAlias == "user") {
            if (Gdn::session()->isValid()) {
                $sessionAuthenticator = Gdn::session()->getPreference("Authenticator");
                $authenticationSchemeAlias = $sessionAuthenticator ? $sessionAuthenticator : "default";
            }
        }

        if ($authenticationSchemeAlias == "default") {
            $authenticationSchemeAlias = Gdn::config("Garden.Authenticator.DefaultScheme", "password");
        }

        // Lowercase always, for great justice
        $authenticationSchemeAlias = strtolower($authenticationSchemeAlias);

        // Check if we are allowing this kind of authentication right now
        if (!array_key_exists($authenticationSchemeAlias, $this->_AuthenticationSchemes)) {
            throw new Exception(
                "Tried to load authenticator '{$authenticationSchemeAlias}' which was not yet registered."
            );
        }
        if (array_key_exists($authenticationSchemeAlias, $this->_Authenticators)) {
            if ($inheritAuthenticator) {
                $this->_Authenticator = $this->_Authenticators[$authenticationSchemeAlias];
            }
            return $this->_Authenticators[$authenticationSchemeAlias];
        }

        $authenticatorClassName = "Gdn_" . ucfirst($authenticationSchemeAlias) . "Authenticator";
        if (class_exists($authenticatorClassName)) {
            $authenticator = new $authenticatorClassName();
            $this->_Authenticators[$authenticationSchemeAlias] = $authenticator;
            if ($inheritAuthenticator) {
                $this->_Authenticator = $this->_Authenticators[$authenticationSchemeAlias];
            }

            return $this->_Authenticators[$authenticationSchemeAlias];
        }
    }

    /**
     * Enable a particular authentication scheme.
     *
     * @param string $authenticationSchemeAlias
     * @param bool $setAsDefault
     */
    public function enableAuthenticationScheme($authenticationSchemeAlias, $setAsDefault = false)
    {
        // Get list of currently enabled schemes.
        $enabledSchemes = Gdn::config("Garden.Authenticator.EnabledSchemes", []);
        $forceWrite = false;

        // If the list is empty (shouldnt ever be empty), add 'password' to it.
        if (!is_array($enabledSchemes)) {
            $forceWrite = true;
            $enabledSchemes = ["password"];
        }

        // First, loop through the list and remove any instances of the supplied authentication scheme
        $haveScheme = false;
        foreach ($enabledSchemes as $schemeIndex => $schemeKey) {
            if ($schemeKey == $authenticationSchemeAlias) {
                if ($haveScheme === true) {
                    unset($enabledSchemes[$schemeIndex]);
                }
                $haveScheme = true;
            }
        }

        // Now add the new scheme to the list (once)
        if (!$haveScheme || $forceWrite) {
            array_push($enabledSchemes, $authenticationSchemeAlias);
            saveToConfig("Garden.Authenticator.EnabledSchemes", $enabledSchemes);
        }

        if ($setAsDefault == true) {
            $this->setDefaultAuthenticator($authenticationSchemeAlias);
        }
    }

    /**
     * Disable an authentication scheme.
     *
     * @param string $authenticationSchemeAlias
     */
    public function disableAuthenticationScheme($authenticationSchemeAlias)
    {
        $this->unsetDefaultAuthenticator($authenticationSchemeAlias);

        $forceWrite = false;

        // Remove this authenticator from the enabled schemes collection.
        $enabledSchemes = Gdn::config("Garden.Authenticator.EnabledSchemes", []);
        // If the list is empty (shouldnt ever be empty), add 'password' to it.
        if (!is_array($enabledSchemes)) {
            $forceWrite = true;
            $enabledSchemes = ["password"];
        }

        $hadScheme = false;
        // Loop through the list and remove any instances of the supplied authentication scheme
        foreach ($enabledSchemes as $schemeIndex => $schemeKey) {
            if ($schemeKey == $authenticationSchemeAlias) {
                unset($enabledSchemes[$schemeIndex]);
                $hadScheme = true;
            }
        }

        if ($hadScheme || $forceWrite) {
            saveToConfig("Garden.Authenticator.EnabledSchemes", $enabledSchemes);
        }
    }

    /**
     * Unset the default authenticator.
     *
     * @param string $authenticationSchemeAlias
     * @return bool
     */
    public function unsetDefaultAuthenticator($authenticationSchemeAlias)
    {
        $authenticationSchemeAlias = strtolower($authenticationSchemeAlias);
        if (c("Garden.Authenticator.DefaultScheme") == $authenticationSchemeAlias) {
            removeFromConfig("Garden.Authenticator.DefaultScheme");
            return true;
        }

        return false;
    }

    /**
     * Set the default authenticator.
     *
     * @param string $authenticationSchemeAlias
     * @return bool
     */
    public function setDefaultAuthenticator($authenticationSchemeAlias)
    {
        $authenticationSchemeAlias = strtolower($authenticationSchemeAlias);
        $enabledSchemes = Gdn::config("Garden.Authenticator.EnabledSchemes", []);
        if (!in_array($authenticationSchemeAlias, $enabledSchemes)) {
            return false;
        }

        saveToConfig("Garden.Authenticator.DefaultScheme", $authenticationSchemeAlias);
        return true;
    }

    /**
     * Get the authenticator of a given type.
     *
     * @param string $default
     * @return object
     * @throws Exception
     */
    public function getAuthenticator($default = "default")
    {
        if (!$this->_Authenticator) {
            $this->authenticateWith($default);
        }

        return $this->_Authenticator;
    }

    /**
     * Get a list of all the currently available authenticators installed.
     */
    public function getAvailable()
    {
        return $this->_AuthenticationSchemes;
    }

    /**
     * Get the information for an authenticator.
     *
     * @param string $authenticationSchemeAlias
     * @return bool
     */
    public function getAuthenticatorInfo($authenticationSchemeAlias)
    {
        return array_key_exists($authenticationSchemeAlias, $this->_AuthenticationSchemes)
            ? $this->_AuthenticationSchemes[$authenticationSchemeAlias]
            : false;
    }

    /**
     * Replace the placeholders in an authenticator's message.
     *
     * @param string $placeholderString
     * @param array $extraReplacements
     * @return mixed
     */
    public function replaceAuthPlaceholders($placeholderString, $extraReplacements = [])
    {
        $replacements = array_merge(
            [
                "Session_TransientKey" => "",
                "Username" => "",
                "UserID" => "",
            ],
            Gdn::session()->isValid()
                ? [
                    "Session_TransientKey" => Gdn::session()->transientKey(),
                    "Username" => Gdn::session()->User->Name,
                    "UserID" => Gdn::session()->User->UserID,
                ]
                : [],
            $extraReplacements
        );
        return Gdn_Format::vanillaSprintf($placeholderString, $replacements);
    }

    /**
     * Associate a user with a foreign ID.
     *
     * @param string $providerKey
     * @param string $userKey
     * @param int $userID
     * @return array|bool
     */
    public function associateUser($providerKey, $userKey, $userID = 0)
    {
        if ($userID == 0) {
            try {
                $success = Gdn::sql()->insert("UserAuthentication", [
                    "UserID" => 0,
                    "ForeignUserKey" => $userKey,
                    "ProviderKey" => $providerKey,
                ]);
                $success = true;
            } catch (Exception $e) {
                $success = true;
            }
        } else {
            $success = Gdn::sql()->replace(
                "UserAuthentication",
                [
                    "UserID" => $userID,
                ],
                [
                    "ForeignUserKey" => $userKey,
                    "ProviderKey" => $providerKey,
                ]
            );
        }

        if (!$success) {
            return false;
        }

        return [
            "UserID" => $userID,
            "ForeignUserKey" => $userKey,
            "ProviderKey" => $providerKey,
        ];
    }

    /**
     * Get the association for a user an a given authentication provider.
     *
     * @param string $userKey
     * @param bool $providerKey
     * @param string $keyType
     * @return array|bool|stdClass
     */
    public function getAssociation($userKey, $providerKey = false, $keyType = Gdn_Authenticator::KEY_TYPE_TOKEN)
    {
        $query = Gdn::sql()
            ->select("ua.UserID, ua.ForeignUserKey, uat.Token")
            ->from("UserAuthentication ua")
            ->join("UserAuthenticationToken uat", "ua.ForeignUserKey = uat.ForeignUserKey", "left")
            ->where("ua.ForeignUserKey", $userKey)
            ->where("UserID >", 0);

        if ($providerKey && $keyType == Gdn_Authenticator::KEY_TYPE_TOKEN) {
            $query->where("uat.Token", $providerKey);
        }

        if ($providerKey && $keyType == Gdn_Authenticator::KEY_TYPE_PROVIDER) {
            $query->where("ua.ProviderKey", $providerKey);
        }

        $userAssociation = $query->get()->firstRow(DATASET_TYPE_ARRAY);
        return $userAssociation ? $userAssociation : false;
    }

    /**
     *
     */
    public function allowHandshake()
    {
        $this->_AllowHandshake = true;
    }

    /**
     * Determine whether or not an authenticator can handshake.
     *
     * @return bool
     */
    public function canHandshake()
    {
        return $this->_AllowHandshake;
    }

    /**
     * Get whether or not this is the primary authenticator.
     *
     * @param string $authenticationSchemeAlias
     * @return bool
     */
    public function isPrimary($authenticationSchemeAlias)
    {
        return $authenticationSchemeAlias == strtolower(Gdn::config("Garden.Authenticator.DefaultScheme", "password"));
    }

    /**
     * Returns the unique id assigned to the user in the database.
     *
     * This is retrieved from the session cookie if the cookie authenticates) or false if not found or authentication fails.
     *
     * @return int
     */
    public function getIdentity()
    {
        $result = $this->getRealIdentity();

        if ($result < 0) {
            $result = 0;
        }

        return $result;
    }

    /**
     * Returns the unique id assigned to the session in the database.
     *
     * This is retrieved from the session cookie if the cookie authenticates or false if not found or authentication fails.
     *
     * @return string
     */
    public function getSession(): string
    {
        $result = $this->_Identity->getSession();

        return stringIsNullOrEmpty($result) ? "" : $result;
    }

    /**
     * Returns the array of the session in the database.
     *
     * This is retrieved from the session cookie if the cookie authenticates or false if not found or authentication fails.
     *
     * @return array
     */
    public function getSessionArray(): array
    {
        $result = $this->_Identity->getSessionArray();
        return $result ?? [];
    }

    /**
     *
     *
     * @return mixed
     */
    public function getRealIdentity()
    {
        $result = $this->_Identity->getIdentity();
        return $result;
    }

    /**
     * Get the active {@link PermissionModel}.
     *
     * @return PermissionModel
     */
    public function getPermissionModel()
    {
        if ($this->_PermissionModel === null) {
            $this->_PermissionModel = Gdn::permissionModel();
        }
        return $this->_PermissionModel;
    }

    /**
     * Get the active {@link UserModel}.
     *
     * @return UserModel
     */
    public function getUserModel()
    {
        if ($this->_UserModel === null) {
            $this->_UserModel = Gdn::userModel();
        }
        return $this->_UserModel;
    }

    /**
     * Sets identity in the session cookie.
     *
     * @param int|null $value User ID .
     * @param bool $persist Is this sessions persistent.
     * @param string|null $sessionID DB Session ID.
     */
    public function setIdentity(int $value = null, bool $persist = false, string $sessionID = null)
    {
        $this->_Identity->setIdentity($value, $persist, $sessionID);
    }

    /**
     * Get the identity in use for this authenticator.
     *
     * @return Gdn_CookieIdentity
     */
    public function identity()
    {
        if (is_null($this->_Identity)) {
            $this->_Identity = Gdn::factory("Identity");
            $this->_Identity->init();
        }

        return $this->_Identity;
    }

    public function setPermissionModel($permissionModel)
    {
        $this->_PermissionModel = $permissionModel;
    }

    public function setUserModel($userModel)
    {
        $this->_UserModel = $userModel;
    }

    /**
     * Sets/gets the protocol for authentication (http or https).
     *
     * @return string
     */
    public function protocol($value = null)
    {
        if (!is_null($value) && in_array($value, ["http", "https"])) {
            $this->_Protocol = $value;
        }

        return $this->_Protocol;
    }

    /**
     *
     *
     * @param $user
     * @return bool
     */
    public function returningUser($user)
    {
        if ($this->_Identity->hasVolatileMarker($user->UserID)) {
            return false;
        }

        return true;
    }

    /**
     * Returns the url used to register for an account in the application.
     *
     * @return string
     */
    public function registerUrl($redirect = "/")
    {
        return $this->_getURL(Gdn_Authenticator::URL_REGISTER, $redirect);
    }

    /**
     * Returns the url used to sign in to the application.
     *
     * @return string
     */
    public function signInUrl($redirect = "/")
    {
        return $this->_getURL(Gdn_Authenticator::URL_SIGNIN, $redirect);
    }

    /**
     * Returns the url used to sign out of the application.
     *
     * @return string
     */
    public function signOutUrl($redirect = "/")
    {
        return $this->_getURL(Gdn_Authenticator::URL_SIGNOUT, $redirect);
    }

    /**
     * Get the URL used to register a new account with this authenticator.
     *
     * @param string $redirect
     * @return bool|mixed|string
     */
    public function remoteRegisterUrl($redirect = "/")
    {
        return $this->_getURL(Gdn_Authenticator::URL_REMOTE_REGISTER, $redirect);
    }

    /**
     * Get the URL used to sign in on the remote site.
     *
     * @param string $redirect
     * @return bool|mixed|string
     */
    public function remoteSignInUrl($redirect = "/")
    {
        return $this->_getURL(Gdn_Authenticator::URL_REMOTE_SIGNIN, $redirect);
    }

    /**
     * Get the URL used to sign out of the remote site.
     *
     * @param string $redirect
     * @return bool|mixed|string
     */
    public function remoteSignOutUrl($redirect = "/")
    {
        return $this->_getURL(Gdn_Authenticator::URL_REMOTE_SIGNOUT, $redirect);
    }

    /**
     * Get the URL of a given type.
     *
     * @param string $uRLType
     * @param string $redirect
     * @return bool|mixed|string
     */
    public function getURL($uRLType, $redirect)
    {
        return $this->_getURL($uRLType, $redirect);
    }

    /**
     * Get the URL of a given type.
     *
     * @param string $uRLType
     * @param string $redirect
     * @return bool|mixed|string
     */
    protected function _getURL($uRLType, $redirect)
    {
        $sessionAuthenticator = Gdn::session()->getPreference("Authenticator");
        $authenticationScheme = $sessionAuthenticator ? $sessionAuthenticator : "default";

        try {
            $authenticator = $this->getAuthenticator($authenticationScheme);
        } catch (Exception $e) {
            $authenticator = $this->getAuthenticator();
        }

        if (!is_null($redirect) && ($redirect == "" || $redirect == "/")) {
            $redirect = Gdn::router()->getDestination("DefaultController");
        }

        if (is_null($redirect)) {
            $redirect = "";
        }

        // Ask the authenticator for this URLType
        $return = $authenticator->getURL($uRLType);

        // If it doesn't know, get the default from our config file
        if (!$return) {
            $return = c("Garden.Authenticator." . $uRLType, false);
        }
        if (!$return) {
            return false;
        }

        $extraReplacementParameters = [
            "Path" => $redirect,
            "Scheme" => $authenticationScheme,
        ];

        // Extended return type, allows provider values to be replaced into final URL
        if (is_array($return)) {
            $extraReplacementParameters = array_merge($extraReplacementParameters, $return["Parameters"]);
            $return = $return["URL"];
        }

        $fullRedirect = $redirect != "" ? url($redirect, true) : "";
        $extraReplacementParameters["Redirect"] = $fullRedirect;
        $extraReplacementParameters["CurrentPage"] = $fullRedirect;

        // Support legacy sprintf syntax
        $return = sprintf($return, $authenticationScheme, urlencode($redirect), $fullRedirect);

        // Support new named parameter '{}' syntax
        $return = $this->replaceAuthPlaceholders($return, $extraReplacementParameters);

        if ($this->protocol() == "https") {
            $return = str_replace("http:", "https:", url($return, true));
        }

        return $return;
    }

    /**
     *
     *
     * @param $authResponse
     * @param null $userData
     * @throws Exception
     */
    public function trigger($authResponse, $userData = null)
    {
        if (!is_null($userData)) {
            $this->EventArguments["UserData"] = $userData;
        } else {
            $this->EventArguments["UserData"] = false;
        }

        switch ($authResponse) {
            case Gdn_Authenticator::AUTH_SUCCESS:
                $this->fireEvent("AuthSuccess");
                break;
            case Gdn_Authenticator::AUTH_PARTIAL:
                $this->fireEvent("AuthPartial");
                break;
            case Gdn_Authenticator::AUTH_DENIED:
                $this->fireEvent("AuthDenied");
                break;
            case Gdn_Authenticator::AUTH_INSUFFICIENT:
                $this->fireEvent("AuthInsufficient");
                break;
            case Gdn_Authenticator::AUTH_PERMISSION:
                $this->fireEvent("AuthPermission");
                break;
            case Gdn_Authenticator::AUTH_ABORTED:
                $this->fireEvent("AuthAborted");
                break;
            case Gdn_Authenticator::AUTH_CREATED:
                $this->fireEvent("AuthCreated");
                break;
        }
    }
}
